package security;

import security.exception.HandShakeFailedException;
import analyticsServer.UserEvent;
import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
import java.security.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.crypto.KeyGenerator;
import javax.crypto.Mac;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import org.bouncycastle.util.encoders.Base64;
import security.exception.ConnectFailedException;
import security.exception.KeyLoadingFailedException;
import server.Server;
import server.UserManager;
import server.data.User;

/**
 * Class that encapsulates the other channels and switches between them, also
 * includes Handshake.
 *
 * @author Group 95
 */
public class MasterChannel {

    private static final Logger logger = Logger.getLogger("MasterChannel");
    private static int AES_KEYSIZE = 256;
    private static int STATE_UNENCRYPED = 0;
    private static int STATE_ENCRYPED = 1;
    private static int STATE_HAMAC = 2;
    private static int STATE_HAMAC_RETURN = 3;
    private TCPChannel unencryptedChannel;
    private Channel base64Channel;
    private Channel handShakeChannel;
    private Channel encryptedChannel;
    private Channel currentChannel;
    private Socket socket;
    private User user;
    private int state;
    private boolean switched;
    private boolean reread;
    private final String lock_read = "lock_read", lock_switch = "lock_switch";
    private final String serverHost;
    private final int tcpPort;

    {
        logger.setLevel(Level.ALL);
    }

    public MasterChannel(final String serverHost, final int tcpPort) throws UnknownHostException, IOException {
        this(new Socket(serverHost, tcpPort));
        logger.log(Level.INFO, "MasterChannel serverHost: {0} ,tcpPort: {1}", new Object[]{serverHost, tcpPort});
    }

    public MasterChannel(final Socket socket) throws UnknownHostException, IOException {
        logger.log(Level.INFO, "MasterChannel socket: {0}", new Object[]{socket});
        unencryptedChannel = new TCPChannel(socket);
        this.socket = socket;
        this.serverHost=socket.getInetAddress().getHostAddress();
        this.tcpPort=socket.getPort();
        base64Channel = new Base64Channel(unencryptedChannel);
        this.currentChannel = unencryptedChannel;
        state = MasterChannel.STATE_UNENCRYPED;
        switched = false;
        reread = false;
    }
    
    public void reconnect() throws ConnectFailedException
    {
        try {
            this.socket=new Socket(this.serverHost,this.tcpPort);
            this.unencryptedChannel.setSocket(this.socket);
        } catch (UnknownHostException ex) {
            logger.log(Level.SEVERE, null, ex);
            throw new ConnectFailedException("Unknown Host "+this.serverHost+" in reconnect");
        } catch (IOException ex) {
            logger.log(Level.SEVERE, null, ex);
            throw new ConnectFailedException("IOException trying to reconnect");
        }
    }

    /**
     * Checks if something is readable from the underlying socket
     *
     * @return true if something can be read from the socket
     */
    public boolean receivable() throws IOException {
        return this.currentChannel.receivable();
    }

    public String receiveMessage() throws IOException {
        String strRet;
        synchronized (lock_read) //message should get received with old channel first
        {
            final byte[] bRec = currentChannel.receiveMessage();
            if (bRec == null) {
                strRet = null;
            } else {
                strRet = new String(bRec);
            }
            synchronized (lock_switch) {
                if (switched) {
                    switched = false;
                    if (reread) //is set in case of an logout, 
                    {
                        reread = false;
                        strRet = new String(currentChannel.reReceiveMessage());
                    } else {
                        strRet = "";
                    }
                    try {
                        lock_read.wait(); //wait until handshake is over
                    } catch (InterruptedException ex) {
                        logger.log(Level.SEVERE, null, ex);
                    }
                }
            }
        }
        logger.log(Level.INFO, "MasterChannel receiveMessage: {0}", new Object[]{strRet});

        //check integrity
        if (state >= STATE_HAMAC) {
            String output = strRet.substring(0, strRet.lastIndexOf(" "));
            logger.log(Level.INFO, "hmac main message: {0}", new Object[]{output});
            if (!checkHMAC(strRet)) {
                logger.log(Level.INFO, "Invalid HMAC");
                if (state == STATE_HAMAC_RETURN) {
                    state = STATE_ENCRYPED;
                    return output;
                }
                state++;
                sendMessage("!list");
                return output;
            }
            state = STATE_ENCRYPED;
            return output;
        }
        return strRet;
    }

//login wird geschickt und switch erfolgt, antwort mit receiveMessage im TCPListenerThread gelesen, soll dort ignoriert werden und mit reRead im handshake gelesen werde
    public void sendMessage(final String strMessage) throws IOException {
        currentChannel.sendMessage(strMessage.getBytes());
    }

    /**
     * Send message with HMAC-integrity check. Set state for checking HMAC on
     * receive.
     *
     * @param strMessage
     * @throws IOException
     */
    public void sendMessageHMAC(final String strMessage) throws IOException {
        logger.log(Level.INFO, "channel for hmac, user: {0}", new Object[]{user});
        if (user != null && state == STATE_ENCRYPED) {
            logger.log(Level.INFO, "set state hmac");
            state = STATE_HAMAC;
        }
        sendMessage(strMessage);
    }

    /*
     * Check message integrity: check if received HMAC equals new HMAC.
     */
    private boolean checkHMAC(String message) {
        String output = message.substring(0, message.lastIndexOf(" "));
        byte[] clientHMAC = createHMAC(output).getBytes();
        byte[] serverHMAC = (message.substring(message.lastIndexOf(" ") + 1)).getBytes();
        logger.log(Level.INFO, "HMACs: {0} {1}", new Object[]{new String(clientHMAC), new String(serverHMAC)});
        return MessageDigest.isEqual(clientHMAC, serverHMAC);
    }

    /**
     * Creates HMAC-code for message integrity check. Hash based on message, key
     * only available when user is set.
     *
     * @param strMessage
     * @throws IOException
     */
    public String createHMAC(final String strMessage) {
        String hmac = null;
        try {
            if (user == null) {
                logger.log(Level.INFO, "Can't read hmac, user not set, return null");
            }
            Key secretKey = KeyLoader.loadHMACKey(user.getStrName());
            Mac hMac = Mac.getInstance("HmacSHA256");
            hMac.init(secretKey);
            hMac.update(strMessage.getBytes());
            byte[] hash = hMac.doFinal();
            logger.log(Level.INFO, "hash: {0}", new Object[]{new String(hash)});
            hmac = new String(Base64Coder.base64encodeParts(new String(hash)));
        }
         catch (NoSuchAlgorithmException nsax) {
            logger.log(Level.INFO, "Can't apply hmac - no such algorithm");
        } catch (InvalidKeyException ikex) {
            logger.log(Level.INFO, "invalid userkey");
        } catch (KeyLoadingFailedException ikex) {
            logger.log(Level.INFO, "could not load HMAC",ikex);
        }
        return hmac;
    }

    private byte[] generateChallenge(byte[] challenge) {
        final SecureRandom secureRandom = new SecureRandom();
        secureRandom.nextBytes(challenge);
        return challenge;
    }

    private SecretKey generateAESSecretKey() throws NoSuchProviderException, NoSuchAlgorithmException {
        final KeyGenerator generator = KeyGenerator.getInstance("AES", "BC");
        // KEYSIZE is in bits
        generator.init(AES_KEYSIZE);
        return generator.generateKey();
    }

    public void doClientHandShake(final String username, final int tcpPort, final PrivateKey pkReceive, final PublicKey pkSend) throws HandShakeFailedException {
        try {
            this.handShakeChannel = new RSAChannel(base64Channel, pkReceive, pkSend, username, "auction-server");
            StringBuilder strB = new StringBuilder(Base64Coder.encode("!login")).append(' ');
            strB.append(Base64Coder.encode(username)).append(' ');
            strB.append(Base64Coder.encode(Integer.toString(tcpPort))).append(' ');

            byte[] clientHandshake = Base64.encode(generateChallenge(new byte[32]));
            strB.append(new String(clientHandshake));

            String strClientChallenge = new String(Base64.decode(clientHandshake));
            synchronized (lock_switch) {
                switched = true;
            }
            handShakeChannel.sendMessage(strB.toString().getBytes());

            synchronized (lock_read) //wait until message got received by tcpchannel and rereceive it afterwards
            {
                final String serverAnswer = new String(handShakeChannel.reReceiveMessage());

                final String[] parts = serverAnswer.split(" ");
                if (parts.length != 5) {
                    throw new HandShakeFailedException("Handshake failed (invalid Serveranswer - Messagelength)!");
                }
                if (!Base64Coder.decode(parts[0]).equals("!ok")) {
                    throw new HandShakeFailedException("Handshake failed (invalid Serveranswer - no !ok)!");
                }

                if (!Base64Coder.decode(parts[1]).equals(strClientChallenge)) {
                    throw new HandShakeFailedException("Handshake failed (invalid Serveranswer - Client challenge invalid!\n" + parts[1] + "\n" + strClientChallenge);
                }
                encryptedChannel = new AESChannel(base64Channel, parts[3], parts[4]);

                encryptedChannel.sendMessage(parts[2].getBytes()); //send serverchallenge back needn't be decoded
                this.currentChannel = encryptedChannel;
                state = STATE_ENCRYPED;
/*                if ((user = UserManager.getInstance().login(username, socket.getInetAddress(), tcpPort)) == null) {
                    user = UserManager.getInstance().getUser(username);
                }*/
                user=new User(username);
                user.setiPClientPort(tcpPort);
                lock_read.notifyAll();
            }

        } catch (IOException ex) {
            logger.log(Level.SEVERE, "IOException in doClientHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (IOException)!");
        } catch (NoSuchAlgorithmException ex) {
            logger.log(Level.SEVERE, "NoSuchAlgorithmException in doClientHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (NoSuchAlgorithmException)!");
        } catch (NoSuchProviderException ex) {
            logger.log(Level.SEVERE, "NoSuchProviderException in doClientHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (NoSuchProviderException)!");
        } catch (NoSuchPaddingException ex) {
            logger.log(Level.SEVERE, "NoSuchPaddingException in doClientHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (NoSuchPaddingException)!");
        } catch (InvalidKeyException ex) {
            logger.log(Level.SEVERE, "InvalidKeyException in doClientHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (InvalidKeyException)!");
        } catch (InvalidAlgorithmParameterException ex) {
            logger.log(Level.SEVERE, "InvalidAlgorithmParameterException in doClientHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (InvalidAlgorithmParameterException)!");
        }
    }

    //on the server, something other than list is received, so it should be a login
    //==> switch to rsa-channel and reread, set publickeypath from client, send rsa-encrypted reply
    //==> switch to aeschannel and read client reply, if error switch to unencrypted
    public void doServerHandShake(final PrivateKey pkReceive) throws HandShakeFailedException, IOException {
        this.handShakeChannel = new RSAChannel(base64Channel, pkReceive);
        String strClient = new String(handShakeChannel.reReceiveMessage());
        logger.log(Level.INFO, "server handshake rereceive message done {0} ", new Object[]{strClient});
        final String[] parts = strClient.split(" ");
        if (parts.length != 4) {
            throw new HandShakeFailedException("Handshake failed (invalid Client-Request - Messagelength)!");
        }
        if (!Base64Coder.decode(parts[0]).equals("!login")) {
            throw new HandShakeFailedException("Handshake failed (invalid Client-Request - no !login)!");
        }
        //@todo check if user is already logged in, tcp-Port is valid, ....
        final String username = Base64Coder.decode(parts[1]);
        final int userPort = Integer.valueOf(Base64Coder.decode(parts[2]));
        System.out.println("user tcp Port: " + userPort);
        if ((user = UserManager.getInstance().login(username, socket.getInetAddress(), userPort)) == null) {
            user = UserManager.getInstance().getUser(username);
        }
        final String clientChallenge = Base64Coder.decode(parts[3]);

        ((RSAChannel) handShakeChannel).setUserPkSend(Base64Coder.decode(parts[1]));

        try {

            final byte[] serverChallenge = generateChallenge(new byte[32]);
            String strServerChallenge = new String(serverChallenge);
            StringBuffer strB = new StringBuffer(Base64Coder.encode("!ok")).append(' ').append(Base64Coder.encode(clientChallenge)).append(' ');
            strB.append(Base64Coder.encode(serverChallenge)).append(' ');

            final SecretKey secretKey = generateAESSecretKey();
            strB.append(Base64Coder.encode(secretKey.getEncoded())).append(' ');
            final byte[] initVector = generateChallenge(new byte[16]);
            strB.append(Base64Coder.encode(initVector));

            //AESChannel mit Base64 encoded Argumenten gestartet, wie im Client
            //ansonsten Cipher not initialized Exception.
            this.encryptedChannel = new AESChannel(base64Channel, Base64Coder.encode(secretKey.getEncoded()), Base64Coder.encode(initVector));
            handShakeChannel.sendMessage(strB.toString().getBytes());

            strClient = Base64Coder.decode(encryptedChannel.receiveMessage());
            if (!strClient.equals(strServerChallenge)) {
                throw new HandShakeFailedException("Handshake failed (invalid Clientanswer - Server challenge invalid)!");
            }
            this.currentChannel = encryptedChannel;
            state = STATE_ENCRYPED;
        } catch (NoSuchProviderException ex) {
            logger.log(Level.SEVERE, "NoSuchProviderException in doServerHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (NoSuchProviderException)!");
        } catch (NoSuchAlgorithmException ex) {
            logger.log(Level.SEVERE, "NoSuchAlgorithmException in doServerHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (NoSuchAlgorithmException)!");
        } catch (NoSuchPaddingException ex) {
            logger.log(Level.SEVERE, "NoSuchPaddingException in doServerHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (NoSuchPaddingException)!");
        } catch (InvalidKeyException ex) {
            logger.log(Level.SEVERE, "InvalidKeyException in doServerHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (InvalidKeyException)!");
        } catch (InvalidAlgorithmParameterException ex) {
            logger.log(Level.SEVERE, "InvalidAlgorithmParameterException in doServerHandShake", ex);
            throw new HandShakeFailedException("Handshake failed (InvalidAlgorithmParameterException)!");
        }

    }

    /**
     * Logs out the user if still logged in, set channel back to unencrypted.
     * Method is called by both, Client and Server -> both need unencrypted
     * Channel.
     */
    public void logoutClient() throws IOException {
        logger.log(Level.INFO, "set channel back to unencrypted");
        synchronized (lock_switch) {
            this.switched = true;
            this.reread = true;
            state = STATE_UNENCRYPED;
            this.currentChannel = unencryptedChannel;
        }
        encryptedChannel.sendMessage("!logout".getBytes());
        user = null;
        synchronized (lock_read) //wait until response is read from encryptedchannel (should be reread from unencrypted
        {
            lock_read.notifyAll();
        }
    }

    public void logoutServer() throws IOException {
        logger.log(Level.INFO, "set channel back to unencrypted");

        if (user != null) {
            if (UserManager.getInstance().logout(user.getStrName(), user.getIAClientAddress(), user.getiPClientPort()) == null) {
                //should never be reached
                sendMessage("You have to log in first!");
            }
            Server.callAnalyticsServer(new UserEvent(Server.NAME, UserEvent.USER_LOGOUT, System.currentTimeMillis(), user.getStrName()));
            this.currentChannel = unencryptedChannel;
            state = STATE_UNENCRYPED;
            sendMessage("User " + user.getStrName() + " successfully logged out.");
            user = null;
            /*            synchronized(lock_switch) is not needed on server, because on the server no thread is stuck in reading from socket
             {  
             this.switched=true;
             this.reread=true;
             }*/
            logger.log(Level.INFO, "logout user, set user null");
        }
    }

    public void close() {
        currentChannel.close(); //should be enough
    }

    /**
     * Getter for client/user who is using this MasterChannel.
     *
     * @return
     */
    public User getUser() {
        return this.user;
    }
    
    /**
     * Getter for current state of MasterChannel
     * @return 
     */
    public int getState() {
        return state;
    }
}
